为什么需要 ref?
JavaScript 的原始值(如 Number、String、null 等)是按值传递的,与对象不同,它们没有引用关系。这意味着我们无法直接通过 Proxy 拦截原始值的读写操作。例如:
let str = 'vue'
str = 'vue3' // 无法拦截
为了让原始值具备响应式能力,Vue.js 引入了 ref 的概念。简单来说,ref 通过一个对象“包裹”原始值,并将这个对象转为响应式,从而间接实现对原始值的监听和响应。
ref 的基本实现
ref 的核心思想是用一个对象(通常称为“包裹对象”)来存储原始值,并通过 Vue 的 reactive 函数将其转为响应式对象。以下是 ref 的简易实现:
function ref(val) {
const wrapper = {
value: val
}
return reactive(wrapper)
}
通过这样的封装,用户可以这样使用 ref:
const count = ref(1)
effect(() => {
console.log(count.value) // 1
})
count.value = 2 // 触发副作用,打印 2
在这个例子中,count 是一个响应式对象,访问或修改 count.value 会触发相应的副作用函数(如重新渲染)。这种设计解决了两个问题:
- 用户无需手动创建包裹对象:ref 函数自动完成包裹对象的创建,简化了开发流程。
- 规范化包裹对象结构:通过 ref 创建的包裹对象统一使用 value 属性,避免了用户自定义命名带来的不一致性。
如何区分 ref 和普通响应式对象?
尽管 ref 通过包裹对象实现了原始值的响应式,但它与普通的 reactive 对象在结构上并无二致。例如:
const refVal = ref(1)
const reactiveVal = reactive({ value: 1 })
从表面看,refVal 和 reactiveVal 都是包含 value 属性的响应式对象。
那么,如何区分一个对象是否是 ref 呢?这在实现自动脱 ref(稍后介绍)等功能时尤为重要。
Vue.js 的解决方案是为 ref 创建的包裹对象添加一个标识属性 __v_isRef
:
function ref(val) {
const wrapper = {
value: val
}
Object.defineProperty(wrapper, '__v_isRef', {
value: true,
enumerable: false
})
return reactive(wrapper)
}
通过检查 __v_isRef
属性,我们可以明确区分 ref 和普通的 reactive 对象,为后续的优化功能(如自动脱 ref)提供了基础。
解决响应丢失问题
ref 不仅用于原始值的响应式,还能解决 Vue.js 组件开发中的一个常见问题:响应丢失。响应丢失通常发生在使用展开运算符(...)将响应式对象暴露到模板时。来看一个例子:
export default {
setup() {
const obj = reactive({ foo: 1, bar: 2 })
setTimeout(() => {
obj.foo = 100 // 不会触发模板重新渲染
}, 1000)
return { ...obj }
}
}
在模板中:
<template>
<p>{{ foo }} / {{ bar }}</p>
</template>
这里的问题在于,{ ...obj }
创建了一个普通对象,丢失了 obj 的响应式特性。
修改 obj.foo 不会触发模板的重新渲染,因为模板访问的是普通对象的 foo 属性,而非响应式的 obj.foo。
toRef 和 toRefs 的解决方案
为了解决响应丢失问题,Vue.js 提供了 toRef 和 toRefs 两个工具函数。它们的思路是通过为响应式对象的每个属性创建一个 ref 结构的对象,保留与原始响应式数据的联系。
toRef 的实现
toRef 函数将响应式对象的某个属性包装为 ref 结构:
function toRef(obj, key) {
const wrapper = {
get value() {
return obj[key]
},
set value(val) {
obj[key] = val
}
}
Object.defineProperty(wrapper, '__v_isRef', {
value: true
})
return wrapper
}
使用 toRef,我们可以这样重构 newObj:
const obj = reactive({ foo: 1, bar: 2 })
const newObj = {
foo: toRef(obj, 'foo'),
bar: toRef(obj, 'bar')
}
当访问 newObj.foo.value 时,实际上访问的是 obj.foo,修改 newObj.foo.value 也会更新 obj.foo,从而保留响应式能力。
toRefs 批量转换
如果响应式对象的属性较多,逐个调用 toRef 会很繁琐。因此,Vue.js 提供了 toRefs 函数来批量转换:
function toRefs(obj) {
const ret = {}
for (const key in obj) {
ret[key] = toRef(obj, key)
}
return ret
}
使用方式如下:
const obj = reactive({ foo: 1, bar: 2 })
const newObj = { ...toRefs(obj) }
这样,newObj 的每个属性都是一个 ref 结构的对象,访问 newObj.foo.value 相当于访问 obj.foo,修改也会触发响应。
自动脱 ref:优化用户体验
虽然 toRefs 解决了响应丢失问题,但它引入了一个新的挑战:用户必须通过 .value
访问属性值。例如:
const obj = reactive({ foo: 1, bar: 2 })
const newObj = { ...toRefs(obj) }
console.log(newObj.foo.value) // 1
在模板中,用户需要这样写:
<p>{{ foo.value }} / {{ bar.value }}</p>
这显然增加了心智负担,因为用户通常希望直接访问属性值:
<p>{{ foo }} / {{ bar }}</p>
为了解决这个问题,Vue.js 引入了 自动脱 ref 的机制,通过代理自动返回 ref 的 value 属性值。
proxyRefs 的实现
Vue.js 使用 proxyRefs 函数为对象创建代理,实现自动脱 ref:
function proxyRefs(target) {
return new Proxy(target, {
get(target, key, receiver) {
const value = Reflect.get(target, key, receiver)
// 自动脱 ref 实现:如果读取的值是 ref,则返回它的 value 属性值
return value.__v_isRef ? value.value : value
},
set(target, key, newValue, receiver) {
const value = target[key]
if (value.__v_isRef) {
value.value = newValue
return true
}
return Reflect.set(target, key, newValue, receiver)
}
})
}
使用 proxyRefs 后,访问 newObj.foo 会直接返回 obj.foo 的值,而无需显式访问 .value:
const newObj = proxyRefs({ ...toRefs(obj) })
console.log(newObj.foo) // 1
newObj.foo = 100 // 触发响应,修改 obj.foo
自动脱 ref 的广泛应用
自动脱 ref 不仅用于 toRefs 的场景,在 Vue.js 的其他地方也有体现。例如,reactive 对象中的 ref 属性也会自动脱 ref:
const count = ref(0)
const obj = reactive({ count })
console.log(obj.count) // 0,而非 ref 对象
这种设计极大减轻了用户的心智负担,用户无需关心某个值是否为 ref,只需直接访问即可。